Skip to content

feat: readyz: make the overall wait timeout configurable per template - #487

Merged
Dmitry Berkovich (dberkov) merged 5 commits into
mainfrom
feat/long-running-actor-support
Aug 4, 2026
Merged

feat: readyz: make the overall wait timeout configurable per template#487
Dmitry Berkovich (dberkov) merged 5 commits into
mainfrom
feat/long-running-actor-support

Conversation

@mayawang

@mayawang Maya Wang (mayawang) commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Rescoped again. This PR previously proposed --golden-snapshot-warmup, a
tunable wall-clock delay before the golden checkpoint. Per discussion, that
direction is dropped: the answer for a workload that cannot report readiness
is a readiness endpoint — a small sidecar where the workload itself cannot be
changed — not a longer timer. What survives is the piece that discussion
agreed on, and which the previous revision already flagged as a follow-up:
making the readyz deadline itself configurable.

The warmup work is not in this branch. It is kept locally in case a workload
genuinely cannot be given a readiness signal before GA, and would come back as
its own PR if so.

Summary

readyz.Wait polls until the container returns 200 or a hardcoded 30s
elapses. A workload that legitimately takes longer to bind its HTTP server
cannot be accommodated without raising the ceiling for every actor in the
cluster, and losing that race fails the actor start.

How long a workload takes to become ready is a property of that workload, so
this makes the deadline a per-template setting rather than a package constant.

Adds optional timeoutSeconds to ContainerReadyz. Unset keeps today's 30s,
so no existing template changes behavior.

Changes

The value rides on the existing probe, so it follows the chain the probe already
takes and no call site needs to know about it:

ContainerReadyz.timeoutSecondstoAteletReadyzateletpb.Readyz
toAteomReadyzateompb.Readyzreadyz.Wait

  • pkg/api/v1alpha1/actortemplate_types.goTimeoutSeconds *int32,
    +optional, Minimum=1, Maximum=3600.
  • internal/proto/ateletpb/atelet.proto, internal/proto/ateompb/ateom.proto
    int32 timeout_seconds = 2 on both Readyz messages.
  • cmd/ateapi/internal/controlapi/workload_spec.go, cmd/atelet/main.go — pass
    it through the two conversions.
  • internal/readyz/readyz.goOverallTimeout becomes
    DefaultOverallTimeout (still 30s) and Wait resolves its deadline through a
    new overallTimeout(probe) helper.
  • Regenerated: both .pb.go, zz_generated.deepcopy.go, and the
    actortemplates CRD.

None of the four readyz.WaitAll call sites change.

On the zero value. Unlike a warmup delay — where zero is a real request
meaning "checkpoint immediately" — a zero readiness deadline could never be met,
so it is never something a template author means. A non-positive value on the
wire is therefore read as "unset" and falls back to the default, and the CRD
field is a pointer with Minimum=1 so the API rejects 0 outright rather than
silently substituting 30s behind the author's back.

On bounding, which was the open question left on the previous revision:
bounded at 3600. A template asking to wait longer than an hour for readiness
is expressing a broken workload, not a slow one, and the bound keeps a typo from
pinning a worker for a day.

Verification

  • go build ./..., go vet ./..., gofmt, go test ./... — all pass.

  • internal/readyz/readyz_test.gooverallTimeout resolves unset and
    negative to the default and honors an explicit value; Wait against a port
    nothing binds gives up at the probe's 1s deadline rather than the 30s default.

  • workload_spec_test.go, cmd/atelet/main_test.go — the timeout crosses both
    conversions, and a probe without one stays zero on the wire.

  • actortemplate_validation_test.go — the bounds are enforced by a real API
    server. This suite runs under envtest against the generated CRD directory, so
    it exercises the regenerated actortemplates CRD rather than the Go markers:
    300 is accepted, unset is accepted, and 0, -1 and 3601 are all
    rejected by apiserver schema validation.

  • On a real cluster, via CI. internal/e2e/fixtures/probe now declares a
    readyz probe with timeoutSeconds: 60, pointed at the /healthz the probe
    binary already serves on :80. The kind e2e that runs on every PR therefore
    exercises the value crossing ateapi → atelet → ateom on real binaries, across
    the auth matrix, on both the run and restore paths. This is also the readyz
    path's first e2e coverage — no fixture declared a probe before.

Wire compatibility degrades safely in both skew directions: timeout_seconds is
a new field 2 on a Readyz message that has only ever had field 1, so an old
ateom ignores it and an old ateapi leaves it zero, which reads as the 30s
default.

No GKE run. What that would add over the above is a workload whose readiness
genuinely exceeds 30s, and that is the readiness-sidecar work rather than this
PR.

Fixes #<issue_number_goes_here>

It's a good idea to open an issue first for discussion.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

@google-cla

google-cla Bot commented Jul 21, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@mayawang
Maya Wang (mayawang) force-pushed the feat/long-running-actor-support branch 3 times, most recently from 62b205b to a72632b Compare July 22, 2026 03:51
@dberkov

Dmitry Berkovich (dberkov) commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Maya Wang (@mayawang) - I have recently added readyz to the container template -

type ContainerReadyz struct {
. Today it is just action and actually timeout defined as a contant here -
OverallTimeout = 30 * time.Second
.

Have you considered to extend the readyz with a timeout via actorTemplate and push it up to the atelet?

@mayawang

Copy link
Copy Markdown
Collaborator Author

Maya Wang (Maya Wang (@mayawang)) - I have recently added readyz to the container template -

type ContainerReadyz struct {

. Today it is just action and actually timeout defined as a contant here -

OverallTimeout = 30 * time.Second

.
Have you considered to extend the readyz with a timeout via actorTemplate and push it up to the atelet?

Thanks Dmitry Berkovich (@dberkov) — agreed, readyz is the better mechanism. One note on how it fits with this PR: goldenSnapshotWarmupFor() here already returns 0 when every container declares readyz, so the timer is skipped entirely and ATE_GOLDEN_WARMUP_SECONDS is only the fallback for probe-less templates.

Strong +1 on making the timeout configurable — and it's live for us, not hypothetical. The Hermes actor we're onboarding declares readyz (httpGet /health) and takes ~20s to golden-warm against the 30s ceiling. It fits today, but that number grows as we add tools/MCP to the image, and there's no knob to turn: once a probe is declared, ATE_GOLDEN_WARMUP_SECONDS no longer applies.

The failure mode is harsher than the timer's, too. A WaitAll error propagates out of RunWorkload/RestoreWorkload (cmd/ateom-gvisor/main.go:230,430), so a too-short probe hard-fails create and every restore, rather than just capturing an early golden. (The probe-less case that motivated the env fallback here is a different workload — a multi-process Node.js agent that needs ~30s to initialize and reports healthy too early to gate on.)

Shape I'd propose: optional TimeoutSeconds on ContainerReadyz, default 30 so behavior is unchanged, plumbed ateletpb.Readyzateompb.Readyz into readyz.Wait.

I'd keep it as a separate PR rather than folding it in — this one is internal-only, and adding a v1alpha1 field pulls in codegen and API review. The two don't overlap, so they can land in either order. Happy to take it since I have the workload to validate against, unless you'd rather own it as your API — either way I'd want your input on the field bounds.

@mayawang
Maya Wang (mayawang) force-pushed the feat/long-running-actor-support branch 4 times, most recently from 6a8d224 to cff5609 Compare August 3, 2026 05:03
@mayawang

Copy link
Copy Markdown
Collaborator Author

Dmitry Berkovich (@dberkov) — this has been reshaped since your review, so rather than have you re-read
from memory, here's what actually changed. Would appreciate another pass when you have
time.

Two of the four knobs are gone, not rebased. Request parking landed on main and covers that ground properly: the resume timeout is now failFastResumeBudget plus --parked-request-budget, and the ext_proc timeout derives from the park budget in SetExtProcMessageTimeout. resumer.go isn't touched at all anymore, so there's no overlap with that work.

The two survivors moved from env vars to flags, matching the convention parking established. One correction to my comment abve: ATE_GOLDEN_WARMUP_SECONDS no longer exists. It's --golden-snapshot-warmup on atecontroller (cmd/atecontroller/main.go:57, default 20s). The semantics we discussed are unchanged — goldenSnapshotWarmupFor() still returns 0 the moment every container declares readyz, so it remains the probe-less fallback and never competes with a declared probe. The other knob is --route-timeout on atenet-router.

The runsc opt-out became an automatic capability probe, and that turned up a real bug worth flagging since it's the one change with no e2e coverage. The probe originally shelled runsc help start and grepped the usage — which can never match: -allow-connected-on-save is a top-level flag, and the per-subcommand usage lists only -h/-help even on builds that define it. It would have reported "unsupported" on every build and silently stopped passing the flag where it does work. It's
runsc flags now, checked against five real runsc builds (two that reject the flag, three that accept it — the probe's verdict matches ground truth on all five), with a test pinning the argv because the stubs answer regardless of what they're passed.

Rebased onto current main, so this is now post-atunnel. Relevant to the route timeout: it attaches to the actor_original_dst route that replaced the dynamic_forward_proxy path, and the test pins that cluster name so that if actor traffic ever moves to a different route this fails loudly instead of leaving the timeout governing a route nothing uses. Caveat on the evidence — my /config_dump reading of 10s → 300s was taken before atunnel landed, so it measured the old path. The route identity is pinned by test rather than re-measured; I'll re-read it once we have an atunnel-era worker to point at.

TimeoutSeconds on ContainerReadyz is still queued as its own PR, unchanged from what we landed on — and I'd still like your view on the field bounds before I write it.

@mayawang
Maya Wang (mayawang) force-pushed the feat/long-running-actor-support branch from cff5609 to ee2163b Compare August 3, 2026 13:53
@mayawang Maya Wang (mayawang) changed the title feat: Configurable timeouts + warmup for long-running / large-snapshot actors feat: readyz: make the overall wait timeout configurable per template Aug 3, 2026
The 30s readiness deadline was a package constant, so a workload that
legitimately takes longer to bind its HTTP server could not be
accommodated without raising the ceiling for every actor in the cluster.

How long a workload takes to become ready is a property of that
workload, so this plumbs a per-template timeout through the existing
readyz chain: ContainerReadyz.timeoutSeconds -> ateletpb.Readyz ->
ateompb.Readyz -> readyz.Wait. Unset means the ateom's default, which is
the renamed DefaultOverallTimeout, still 30s.

Zero is not a meaningful deadline here -- unlike a warmup delay, a zero
timeout could never be met -- so a non-positive value on the wire is
read as "unset" and falls back to the default. The CRD field is a
pointer with Minimum=1 so the API rejects it outright rather than
silently substituting.

No readyz.WaitAll call site changes: the timeout rides on the probe.

The e2e probe fixture now declares a readyz probe with a non-default
timeoutSeconds. That gives the readyz path its first e2e coverage and
exercises the value crossing ateapi -> atelet -> ateom on real binaries,
on both the run and restore paths.
@mayawang
Maya Wang (mayawang) force-pushed the feat/long-running-actor-support branch from ee2163b to 45f90d8 Compare August 3, 2026 23:07
Comment thread pkg/api/v1alpha1/actortemplate_types.go
Comment thread cmd/ateapi/internal/controlapi/workload_spec.go Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 minor comments, otherwise LGTM

Per review: declare the 30s default with +kubebuilder:default=30 so it is
visible on the stored object and in `kubectl explain`, rather than being a
constant an author has to know the ateom applies.

With the API server defaulting the field, the nil check in toAteletReadyz
is no longer load-bearing and becomes a plain deref. It stays nil-safe:
the conversion is also reachable with an in-process template that never
went through admission, and zero on the wire already means the same 30s.

envtest now asserts the read-back value is 30, so the default is checked
against a real API server rather than only the marker.
Comment thread pkg/api/v1alpha1/actortemplate_validation_test.go Outdated
Comment thread cmd/ateapi/internal/controlapi/workload_spec.go
Comment thread cmd/ateapi/internal/controlapi/workload_spec_test.go Outdated
Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
Maya Wang added 2 commits August 3, 2026 21:38
Per review, following the API convention for CRDs: when the zero value is not
valid, a pointer buys nothing. Minimum=1 rejects an explicit 0 before any
controller sees the object, and +kubebuilder:default=30 fills in the rest, so
the field always holds valid data and the conversion is a plain assignment
rather than a deref behind a nil check.

Also folds the readyz defaulting assertions into TestActorTemplateValidation
alongside the other readyz cases, via an optional verify hook for cases that
check what the API server stored rather than whether it accepted the create.
The standalone path-default test goes away with it.

The explicit-zero case goes too: omitempty makes 0 indistinguishable from unset
through a typed client, so it now defaults to 30 rather than being rejected.
The -1 case still covers the Minimum=1 bound that rejects a manifest spelling
out 0.
With TimeoutSeconds a plain int32, toAteletReadyz assigns it
unconditionally, so a container whose probe omits the timeout no longer
exercises a distinct path through the conversion.
Comment thread cmd/ateapi/internal/controlapi/workload_spec.go Outdated
@mayawang

Copy link
Copy Markdown
Collaborator Author

2 minor comments, otherwise LGTM

Thanks — all addressed, PTAL.

One consequence worth flagging: with omitempty on a non-pointer, a Go client that sets 0 doesn't serialize the field, so it's read as unset and defaults to 30 rather than being rejected. A manifest that spells out 0 still hits Minimum=1. I dropped the envtest case for explicit zero since it can't be expressed through the typed client; the -1 case still covers the bound.

@dberkov
Dmitry Berkovich (dberkov) merged commit 9e3ee7a into main Aug 4, 2026
13 of 15 checks passed
Lior Lieberman (LiorLieberman) pushed a commit that referenced this pull request Aug 5, 2026
#714)

> Split out of #487, which bundled three unrelated changes.

## Summary

Envoy's end-to-end timeout on the workload route is hardcoded at `10s`
in
`buildRoutes`. An actor that legitimately holds a request open longer
gets cut
off: a harness relaying an LLM completion keeps the request open for the
whole
generation, and the client sees a **504 mid-turn**.

Adds `--route-timeout` on atenet-router, and pairs it with a route-level
`idle_timeout` so the ceiling is actually reachable. **The default is
10s, so
behavior is unchanged** unless an operator passes the flag.

## Why the route timeout alone was not enough

Raised in review by @LiorLieberman and @yan-vlasov, and they were right
— the
first version of this PR did not do what it claimed.

We never set `stream_idle_timeout` on the HTTP connection manager, so
Envoy
applies its default of **5 minutes**. Per the HCM proto, that default is
"overridable by the route-level `idle_timeout`", and when it fires "the
stream
is terminated with a 408 Request Timeout error code if no upstream
response
header has been received, otherwise a stream reset occurs."

That is exactly this PR's case. A turn relaying a non-streaming
completion sends
no bytes at all while the actor is thinking, and a request parked across
a
suspend/resume is idle by the same measure. Both are progressing; Envoy
cannot
tell. So `--route-timeout=30m` would still have been cut at 5 minutes
with a
408 — the knob would have looked like it worked and silently not.

`routeIdleTimeout()` therefore resolves the accompanying idle timeout as
`max(routeTimeout, 5m)`. Taking the larger keeps the operator's ceiling
honest
without ever making the idle timer *stricter* than it is today: below 5
minutes
the route timeout fires first regardless, so at the 10s default this is
a no-op.

Route-level rather than HCM-level, so it stays scoped to workload
traffic
instead of every stream through the router. It is derived rather than
exposed as
a second `--route-idle-timeout` flag so the two cannot drift apart, with
one
silently defeating the other — happy to make it explicit if reviewers
prefer.

For naming: what this PR sets is the route-level `timeout`, which bounds
upstream response time. Envoy's HCM `request_timeout` bounds how long
the
*request* takes to be received, which is not the limit in question here.

## Changes

`cmd/atenet/internal/router/` — adds `XdsServer.routeTimeout` with a
`SetRouteTimeout` setter and a `defaultRouteTimeout` const, wired from
`routerConfig.RouteTimeout` / `--route-timeout`. Same shape as the
adjacent
`SetExtProcMessageTimeout` and `SetExtProcMaxRequests`, and a flag on
the
existing config struct rather than an env read, matching the convention
the
parked-request work established. Wired in `startEnvoyDataplane`.

Adds `envoyDefaultStreamIdleTimeout` (5m) and `routeIdleTimeout()`,
applied as
the route's `IdleTimeout` in `buildRoutes`.

A non-positive value leaves the default in place, since Envoy reads a
zero route
timeout as *no timeout at all*.

The knob bounds the actor's own handling time only. The resume that may
precede
a request is covered by request parking and the ext_proc message
timeout, both
of which already derive from `--parked-request-budget`.

`manifests/ate-install/atenet-router.yaml` documents it as a
commented-out entry.

## Verification

- `go build ./...`, `go vet ./...`, `go test ./...` — all pass.
- `xds_test.go` reads the timeout back out of `buildRoutes`, where Envoy
  actually picks it up: default, setter override, and
  non-positive-keeps-default. The helper pins that route to
`OriginalDstClusterName` — a change that moved actor traffic onto some
other
route would otherwise leave the test passing while the timeout governed
a
  route nothing uses.
- Two added subtests cover the pairing:
`IdleTimeoutTracksLongerRouteTimeout`
  and `IdleTimeoutKeepsEnvoyDefaultWhenRouteTimeoutIsShorter`.
- **On a live GKE cluster**, read back out of Envoy's own
`/config_dump`. With
the new image and no flag, the workload route reports `timeout: 10s`, so
the
  default is genuinely unchanged. With `--route-timeout=5m` it reports
  `timeout: 300s`. Same binary, same manifest, only the flag differs.

Caveat on that measurement: it was taken before `ingress: route actor
ingress
through the atunnel mTLS server` landed, so the route it read was the
old
  `dynamic_forward_proxy` path to pod-IP:80. After rebasing, the timeout
attaches to the `actor_original_dst` route that replaced it — which is
now
pinned by the test above rather than left to inspection. The
`idle_timeout`
  pairing has test coverage only, not a live `/config_dump` read.
- **Regression, resume with parking on the path:** a conversation actor
that had
been suspended for 4 days was resumed by an ordinary request through the
  router — HTTP 200 in 3.74s, exactly one parked request,
`parking_wait_duration_seconds{outcome="served"} = 3.459s`, no shed and
no
  `budget_exhausted`.

## Follow-up

Per-ActorTemplate (or per-request) configurability, raised by @ronlv10:
agreed
it needs an API and is follow-up shaped rather than something to fold in
here.
The global flag remains useful as the cluster-wide ceiling.

## Relationship to #465

This is a stopgap for the connected-socket suspend/restore problem
tracked in
**#465 (suspend-safe actor networking)**. Once actor network traffic
survives
checkpoint/restore natively, much of the need to raise this ceiling
should go
away; this just makes the current behavior tunable in the meantime.


Fixes #<issue_number_goes_here>

> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR

---------

Co-authored-by: Maya Wang <mymaya@google.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants